From 6cc0bfd1d79074df790272b8091b1f0226d14283 Mon Sep 17 00:00:00 2001
From: zwlucas <lucas.fariamo08@gmail.com>
Date: Fri, 29 May 2026 15:57:37 -0300
Subject: feat: upload project

Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
---
 frontend/src/routes/services/[id]/+page.svelte | 261 +++++++++++++++++++++++++
 1 file changed, 261 insertions(+)
 create mode 100644 frontend/src/routes/services/[id]/+page.svelte

(limited to 'frontend/src/routes/services/[id]/+page.svelte')

diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte
new file mode 100644
index 0000000..36ea105
--- /dev/null
+++ b/frontend/src/routes/services/[id]/+page.svelte
@@ -0,0 +1,261 @@
+<script lang="ts">
+	import { onMount } from 'svelte';
+	import { page } from '$app/stores';
+	import { getHistory, getServiceStats, listServices } from '$lib/api';
+	import { subscribeHeartbeats } from '$lib/realtime';
+	import type { Service, Heartbeat, ServiceStats } from '$lib/types';
+
+	let svc: Service | undefined = $state();
+	let history: Heartbeat[] = $state([]);
+	let stats: ServiceStats | null = $state(null);
+	let loading = $state(true);
+	let expandedError: number | null = $state(null);
+
+	onMount(async () => {
+		try {
+			const all = await listServices();
+			svc = all.find((s) => s.id === Number($page.params.id));
+			history = await getHistory(Number($page.params.id));
+			getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+		} catch {
+			// handle
+		} finally {
+			loading = false;
+		}
+	});
+
+	$effect(() => {
+		if (!svc && $page.params.id) {
+			getHistory(Number($page.params.id))
+				.then((h) => (history = h))
+				.catch(() => {});
+		}
+	});
+
+	onMount(() => {
+		const unsub = subscribeHeartbeats((hb) => {
+			if (hb.service_id !== Number($page.params.id)) return;
+			history = [...history, hb].slice(-50);
+		});
+		return unsub;
+	});
+
+	let chartPad = { t: 8, b: 20, l: 40, r: 12 };
+	let chartW = $derived(Math.max(history.length * 32, 300));
+	let chartH = 200;
+	let plotW = $derived(chartW - chartPad.l - chartPad.r);
+	let plotH = $derived(chartH - chartPad.t - chartPad.b);
+	let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
+	let chartMin = $derived(0);
+
+	let dots = $derived(
+		history.map((h, i) => {
+			const x = history.length === 1
+				? chartPad.l + plotW / 2
+				: chartPad.l + (i / (history.length - 1)) * plotW;
+			const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+			return { x, y, ...h };
+		})
+	);
+
+	let yTicks = $derived.by(() => {
+		const n = 4;
+		return Array.from({ length: n + 1 }, (_, i) => {
+			const val = chartMin + ((chartMax - chartMin) / n) * i;
+			const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
+			return { val: Math.round(val), y };
+		});
+	});
+</script>
+
+<svelte:head>
+	<title>{svc?.name ?? 'Detalhes'} — YAUM</title>
+</svelte:head>
+
+{#if loading}
+	<div class="flex items-center justify-center py-20">
+		<div
+			class="h-8 w-8 animate-spin rounded-full border-2 border-[var(--border-color)] border-t-[var(--green)]"
+		></div>
+	</div>
+{:else if !svc}
+	<div class="rounded-xl border border-[var(--border-color)] p-12 text-center">
+		<p class="text-sm text-[var(--text-muted)]">Serviço não encontrado</p>
+	</div>
+{:else}
+	<div class="mb-6">
+		<a
+			href="/"
+			class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"
+		>
+			← Voltar
+		</a>
+	</div>
+
+	<div class="mb-8">
+		<h1 class="text-2xl font-semibold text-white">{svc.name}</h1>
+		<p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p>
+	</div>
+
+	<div class="grid gap-6 lg:grid-cols-3">
+		<!-- Stats card -->
+		{#if stats}
+			<div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+				<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+					Uptime
+				</h2>
+				<div class="space-y-3">
+					{#each [
+						{ label: '24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h },
+						{ label: '7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d },
+						{ label: '30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d }
+					] as item}
+						<div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 p-3">
+							<div class="mb-1 flex items-center justify-between">
+								<span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
+									{item.label}
+								</span>
+								<span class="font-mono text-xs tabular-nums text-[var(--text-secondary)]">
+									{item.checks} checks
+								</span>
+							</div>
+							<div class="flex items-baseline gap-3">
+								<span
+									class="text-lg font-bold tabular-nums"
+									style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}"
+								>
+									{item.uptime.toFixed(2)}%
+								</span>
+								<span class="font-mono text-xs text-[var(--text-muted)]">
+									{item.avg.toFixed(0)}ms méd.
+								</span>
+							</div>
+						</div>
+					{/each}
+				</div>
+			</div>
+		{/if}
+
+		<!-- Tempo de Resposta (ms) -->
+		<div
+			class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2"
+		>
+			<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+				Tempo de Resposta (ms)
+			</h2>
+			<div class="relative h-48">
+				{#if history.length === 0}
+					<div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
+						Sem dados ainda
+					</div>
+				{:else if history.length === 1}
+					<div class="flex h-full flex-col items-center justify-center gap-1">
+						<span
+							class="text-3xl font-bold tabular-nums"
+							style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+						>
+							{history[0].response_time_ms}
+							<span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
+						</span>
+						<span class="text-xs text-[var(--text-muted)]">
+							{history[0].is_up ? 'Online' : 'Offline'} —
+							{history[0].status_code || 'timeout'}
+						</span>
+					</div>
+				{:else}
+					<svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+						{#each yTicks as tick}
+							<line
+								x1={chartPad.l}
+								y1={tick.y}
+								x2={chartW - chartPad.r}
+								y2={tick.y}
+								stroke="var(--border-color)"
+								stroke-width="1"
+								stroke-dasharray="3,3"
+							/>
+							<text
+								x={chartPad.l - 6}
+								y={tick.y + 3}
+								text-anchor="end"
+								fill="var(--text-muted)"
+								font-size="9"
+							>{tick.val}</text
+							>
+						{/each}
+
+						<polyline
+							points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
+							fill="none"
+							stroke="var(--green)"
+							stroke-width="2"
+							stroke-linecap="round"
+							stroke-linejoin="round"
+						/>
+
+						{#each dots as d}
+							<circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+						{/each}
+					</svg>
+				{/if}
+			</div>
+			<div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
+				<span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
+				<span
+				>{history[history.length - 1]?.tested_at
+					? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
+					: ''}</span
+				>
+			</div>
+		</div>
+
+		<!-- Timeline compacta -->
+		<div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+			<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+				Últimas Verificações
+			</h2>
+			<div class="flex flex-col gap-1.5">
+				{#each history.slice().reverse() as h}
+					<div>
+						<div class="flex items-center justify-between gap-2">
+							<div class="flex items-center gap-2">
+								<span
+									class="h-2 w-2 shrink-0 rounded-full"
+									class:bg-[var(--green)]={h.is_up}
+									class:bg-[var(--red)]={!h.is_up}
+								></span>
+								<span class="font-mono text-xs">
+									{#if h.status_code > 0}
+										{h.status_code}
+									{:else}
+										TIMEOUT
+									{/if}
+								</span>
+							</div>
+							<div class="flex items-center gap-2">
+								<span class="font-mono text-xs text-[var(--text-muted)]">{h.response_time_ms}ms</span>
+								{#if h.error_message}
+									<button
+										onclick={() => (expandedError = expandedError === h.id ? null : h.id)}
+										class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70"
+									>
+										{expandedError === h.id ? '▲ erro' : '▼ erro'}
+									</button>
+								{/if}
+							</div>
+						</div>
+						{#if expandedError === h.id && h.error_message}
+							<div
+								class="mt-1.5 overflow-auto rounded-md border border-[var(--red)]/20 bg-[var(--bg-secondary)]/50 p-2"
+							>
+								<pre class="break-all font-mono text-[11px] leading-relaxed text-[var(--text-muted)]"
+									>{h.error_message}</pre
+								>
+							</div>
+						{/if}
+					</div>
+				{/each}
+			</div>
+		</div>
+	</div>
+{/if}
-- 
cgit v1.2.3

